CVE-2023-21768 Windows内核提权漏洞
简介
对最近的一个Windows提权洞进行分析,漏洞点不是很难,相比于之前分析的CVE-2021-1732,过程还简单一些,主要是学习I/O Ring这种读写原语,感觉后续微软可能会对I/O Ring的预注册输出缓冲区进行一些调整修改。还有就是分析过程中,结合chatGPT,感觉是一个不错的方法。
环境搭建
实验环境
- Windows 11 22H2
- windbg
- Visual Studio 2022
镜像下载:ed2k://|file|zh-cn_windows_11_consumer_editions_version_22h2_updated_nov_2022_x64_dvd_2c7e96c3.iso|5673539584|EB8FF2B481BB6AFE71B2784C6485733B|/
安装镜像就不用多说,网上教程很多,这里需要注意的是exp的编译,由于提权过程中使用到的某些技术,我们需要Visual Studio 2022,并且安装较高版本的win11 SDK,我这里是Windows 11 SDK (10.0.22621.0)
环境搭建好之后,先用编译好的exp进行测试一下。
漏洞原理
漏洞点在AFD.sys这个驱动中,简单了解下AFD.sys,问问chatGPT。
1 | AFD.sys是Windows操作系统中的一个系统文件,是关于网络协议的文件,主要负责Winsock核心服务的实现和运行。Winsock是Windows下网络协议的一个API,通过它应用程序可以访问网络,AFD.sys则是其中实现的核心服务。具体来说,它提供了以下功能: |
根据后面的分析来看,简单讲就是AFD.sys的一个函数afd!AfdNotifyRemoveIoCompletion中出现了漏洞,存在我们可控制的变量,并且还有赋值操作,当我们构造的变量是一个地址的话,就可以将想设置的值赋值到对应的地址空间中,当然想进一步利用,实现提权,还需要其他的技术。
补丁对比
我们将打补丁前后的文件进行对比,找出打补丁的位置。
- AFD.sys / Windows 11 22H2 / 10.0.22621.608 (December 2022)
- AFD.sys / Windows 11 22H2 / 10.0.22621.1105 (January 2023)
只不过感觉实际上对比补丁貌似也是个体力活,里面实际上有很多0.99的,可能也不是那么容易找到修改的地方。
diaphora对比一下反编译出来的伪代码,貌似diaphora可以直接将它认为有区别的函数自动提取出来。
可以看到在进行赋值前,添加了ProbeForWrite函数,来检测目标地址是否可访问,所以这里应该就是漏洞点了。
逆向工程
这里可能需要先提前去了解有点驱动如何编写自定义的派遣函数。
要利用这个漏洞,首先我们需要知道如何到达漏洞点,通过交叉引用,可以发现调用链是这样的AfdFastIoDeviceControl-->AfdNotifySock-->AfdNotifyRemoveIoCompletion()
关键是搞懂AfdFastIoDeviceControl–>AfdNotifySock,我们对AfdNotifySock进行交叉引用,会发现两个表,AfdImmediateCallDispatch
和AfdIrpCallDispatch
,这两个表里面的函数都是AFD驱动程序的调度函数。
接着对AfdImmediateCallDispatch
进行交叉引用,我们将在AfdFastIoDeviceControl()函数中看到下面的代码,实际上对AfdIrpCallDispatch
进行交叉引用,也会在其对应的函数看到类似的代码。
所以我们可以通过代码中的AfdIoctlTable
去获取自定义的控制信号,前面我们已经知道AfdNotifySock
在AfdImmediateCallDispatch
表中的下标是73,通过下面的图片可以知道对应的控制信号是12127h。
知道了控制信号是12127h,获取我们可以在用户层调用DeviceIoControl来访问到这个函数。
庆幸的是x86matthew(一直比较关注的一个国外师傅,经常发布一些创新性的代码)曾经发布了一些代码,其原本是绕过Winsock的API函数,采用NtCreateFile和NtDeviceIoControlFile来进行AFD驱动程序通信,目的为了网络通信的隐蔽性,不容易被检测,但是我们可以借鉴里面的一些代码,方便我们调试AfdIoctlTable,了解如何触发漏洞。
afd!AfdIoctlTable调试
我们的目的是需要搞明白,如何才能到达漏洞点。
编写的测试代码如下,由于我们不知道传入数据是啥,所以直接传入一些字符串AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJKKKKLLL
1 |
|
在afd!AfdNotifySock
打上断点,断下来,r9寄存器指向我们的输入。
接下来是第一个判断
1 | if ( InputBufferLength != 0x30 || OutputBufferLength ) |
所以需要满足
- InputBufferLength == 0x30
- OutputBufferLength==0
- OutputBuffer==0
继续调试,发现对InputBuffer
的一些值进行了判断,并且将*(void **)_InputBuffer
传入了 ObReferenceObjectByHandle()
函数。
不要完全相信反编译的伪代码,其中的一些偏移可能是错误的,最好还是看汇编,或者重新修改一下变量类型,修改为byte,效果如下。
1 | if ( !*(_DWORD *)(*(_QWORD *)&_InputBuffer + 32i64) ) |
所以我们需要对InputBuffer的一些值进行设置,最好是搞个结构体,通过整个函数中出现InputBuffer,可得到下面的结构体,我们可以将其导入ida,让伪代码更加清晰。
1 | typedef struct AFD_NOTIFYSOCK_DATA |
绕过if的一些值的判断是简单的,重点是如何绕过ObReferenceObjectByHandle()
,其返回值必须>=0,这里可以去问问chatGPT,大部分情况下,可以提供非常有效的帮助。
1 | 问:如果第三个参数是IoCompletionObjectType呢 这个对象类型的句柄可以调用什么函数来创建 |
OK,根据其回答来看,应该是可以通过CreateIoCompletionPort或者NtCreateIoCompletion函数来创建有效的IO完成对象的句柄,从而绕过ObReferenceObjectByHandle()
。
继续向下看,有个while循环,貌似需要我们的InputBuffer->pData1
满足一些条件。
所以现在设置
- Data.pData1 = VirtualAlloc(NULL, 0x2000, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
- Data.dwCounter = 0x1;
然后就可以到达AfdNotifyRemoveIoCompletion()
了,其参数是一个数,IoCompletionObject,InputBuffer,目前我们的Data如下,Data.pData2 还未出现,4byte,目前来说不为空即可,但是根据结构体的定义,将其设置为一个地址。
1 | Data.HandleIoCompletion = hCompletion; |
afd!AfdNotifyRemoveIoCompletion调试
接下来分析AfdNotifyRemoveIoCompletion()
,看看需要绕过那些检测,从而到达漏洞点。
需要满足
- InputBuffer->dwLen!=0
- InputBuffer->pData2指向一块内存空间,可写
接下来我们就遇到了IoRemoveIoCompletion
函数,想要到到达漏洞点,貌似我们需要让其返回STATUS_SUCCESS(0)。
chatGPT对IoRemoveIoCompletion
其介绍如下
1 | `IoRemoveIoCompletion`是Windows Driver Kit(WDK)提供的函数,用于将已完成的I/O操作从I/O完成端口的完成队列中移除并返回。 |
所以貌似我们现在需要在执行这个函数前,调用某个函数来向I/O完成队列中添加一个I/O操作,继续向chatGPT提问。
1 | 问:如何向I/O完成端口的完成队列中添加已完成的I/O操作,有那些函数 |
所以我们似乎可以使用上面的函数,来添加已完成的I/O操作,从而使IoRemoveIoCompletion正常返回。
NtSetIoCompletion定义如下
1 | NtSetIoCompletion 是 Windows 操作系统内核中的一个函数,用于向 I/O 完成端口的完成队列中添加一个 I/O 完成包。该函数通常在内核模式下使用,用于驱动程序或其他内核组件实现异步 I/O 操作。 |
现在采用下面的代码来到达漏洞点。
1 | NtCreateIoCompletion(&hCompletion, MAXIMUM_ALLOWED, NULL, 1); |
可以看到成功返回。
IoRemoveIoCompletion
目前已经解决了关于如何到达漏洞点的问题,但是如何设置我们想要的值呢,根据afd!AfdNotifyRemoveIoCompletion
的分析来看,貌似writevalue是由IoRemoveIoCompletion这个函数来决定的,所以我们需要对这个函数进行分析。
这个函数来自于ntoskrnl.exe,找到函数对应的代码,可以看到,writevalue的值是由KeRemoveQueueEx函数返回的
这个函数的作用是让等待队列中的线程或进程不再等待,可以继续执行。如果函数成功地将线程或进程从等待队列中移除,则返回不为0的值;否则,返回0。需要注意的是,一旦线程或进程从等待队列中移除,它的状态会发生变化,后续操作需要根据具体情况而定。
根据调试情况来看,这里的返回值,貌似一直都是0x1,这应该代表成功将一个线程或进程从等待队列中移除吧,或许有其他手段来移除多个线程或进程,从而返回想要的值。
后渗透原语技术I/O Ring
目前我们可以利用漏洞完成任意地址赋值为0x1,这离提权实际上还远远不够,想要提权,至少需要实现任意地址写,任意地址读。
关于I/O Ring的介绍,Yarden Shafir发布了很多文章,I/O 环——当一个 I/O 操作不够时,I/O 环的一年:发生了什么变化?,一个 I/O 环来统治它们:Windows 11 上的完整读/写利用原语,第三篇文章就是讲的一种特定于 Windows 11 22H2+ 的后渗透原语,非常有价值的后渗透原语,只需要一个内核任意写漏洞,甚至可以向本漏洞一样,只能固定写入0x1,都可以采用此后渗透源语来进行利用。
在阅读完相关的资料后,可以感受到Yarden Shafir花费了大量的时间到I/O Ring的逆向研究中,下面我将以简略的方式,讲述下这个技术的原理,由于本人技术有限,如有错误,请谅解。
I/O Ring
I/O Ring,说到底还是用来在计算机硬件和软件之间传输数据的,只是多了个Ring,代表这是一个环形结构,其包含了一个提交队列,如下。
每一个NT_IORING_SQE,都代表着一个I/O操作,目前支持的I/O操作如下,这些操作都会有一个操作码,可通过逆向工程获取,ioringapi.h头文件也包含了。
- IORING_OP_READ:将文件中的数据读入输出缓冲区。对应BuildIoRingReadFile()函数。
- IORING_OP_CANCEL:请求取消文件的挂起操作。对应BuildIoRingCancelRequest()函数。
- IORING_OP_REGISTER_FILES:请求文件句柄的预注册以便稍后处理。对应BuildIoRingRegisterFileHandles()函数。
- IORING_OP_REGISTER_BUFFERS:请求为要读入的文件数据预注册输出缓冲区。对应BuildIoRingRegisterBuffers()函数。
- IORING_OP_WRITE:将输出缓冲区的数据写入文件。对应BuildIoRingWriteFile()函数。
- IORING_OP_FLUSH: 刷新操作。对应BuildIoRingFlushFile()函数。
正是上面的各种操作,组成一个提交队列。
预注册输出缓冲区文件读写-normal
此技术使用的就是预注册输出缓冲区文件读写,其设计到的函数有BuildIoRingReadFile(),BuildIoRingRegisterBuffers(),BuildIoRingWriteFile(),如果想去了解其他的操作,可以自己根据文档编写相应的代码去学习。
这里我为了更好的理解原理,我打算编写一个正常使用API函数实现预注册输出缓冲区文件读写的代码,然后和不正常的方法进行对比,根据文档以及chatGPT,编写了相应功能的代码,实现了两个功能
- 将文件内容读取到预注册缓冲区,读取abc
- 将预注册缓冲区写到文件中,写入ABC
大概步骤是
- CreateIoRing创建IORING
- BuildIoRingRegisterBuffers()申请了2个缓冲区,可通过IoRingBufferRefFromIndexAndOffset的第一个参数来决定用哪一个。
- BuildIoRingReadFile(),读文件中的abc。
- BuildIoRingWriteFile(),向文件中偏移为3的位置写ABC。
1 |
|
效果如下
现在我们就基本上了解了正常情况下预注册输出缓冲区文件读写是如何实现的,借用下文章中的图片。
预注册输出缓冲区文件读写-unusual
上面的情况是我们正常调用相关函数,实现的预注册输出缓冲区文件读写,其过程是非常安全的,其肯定会检测我们读取,或写入的地址是否是用户层的。
接下来先看我们CreateIoRing创建IORING时,会创造的两个结构,这些都是Yarden Shafir通过逆向工程获取到的,我用chatGPT给参数注释了下。
_IORING_OBJECT结构体,内核层的结构体。
1 | typedef struct _IORING_OBJECT |
_HIORING结构体,用户层。
1 | typedef struct _HIORING |
上面两个结构体中我们需要注意的是下面的参数,分别是注册缓冲区的指针,其是相对应的,值应该也是一样的。
- _IORING_OBJECT中的RegBuffers和RegBuffersCount
- _HIORING的RegBufferArray和BufferArraySize
根据Yarden Shafir的研究发现,或许我们可以不调用BuildIoRingRegisterBuffers()去注册缓冲区,如果我们能直接控制注册缓冲区的指针(IoRing->RegBuffers)直接指向我们自己的一个假缓冲区,也可以被认为我们注册了缓冲区,然后再控制列表当中的Address地址为内核的地址,再结合读写文件,就可以做到任意内核地址读写,并且这种并不会被探测,如果缓冲区在注册时是安全的,然后复制到内核分配,那么当它们作为操作的一部分被引用时它们仍然是安全的,这些肯定都是逆向工程那些函数得出的结论,如果十分感兴趣,自己也可以逆着玩。
当然由于我们要修改IoRing->RegBuffers,这个地址在内核空间,所以前提是得有个内核任意地址写漏洞。
需要注意的是在Windows 11 22H2版本下,内核中的缓冲区数组不再是地址和长度的平面数组(IORING_BUFFER_INFO),而是一个新的结构体。
1 | typedef struct _IOP_MC_BUFFER_ENTRY |
需要这样初始一下。
1 | mcBufferEntry->Address = TargetAddress; |
然后我发现内核的这个缓冲区结构貌似和我们正常调用BuildIoRingRegisterBuffers()的不一样,前者是指针数组,后者是结构体数组,可能是内核和用户层有所区别吧。
任意地址写
步骤如下
- 先将value写入到文件中,WriteFile()
- 设置好要写的内核地址TargetAddress
- 调用BuildIoRingReadFile(),读取文件内容到缓冲区,也就是TargetAddress
图片如下。
任意地址读
步骤如下
- 设置好要读的内核地址TargetAddress
- 调用BuildIoRingWriteFile,将TargetAddress的值写入到文件
- ReadFile读取文件里面的值
图片如下
Exp编写
接下来进行Exp的编写。
内核句柄查找
解决了原理的问题,还需要解决如何获取到内核指向_IORING_OBJECT的结构体指针呢,这里需要用到常用的句柄查找技术,步骤如下
- 定义一个PSYSTEM_HANDLE_INFORMATION结构体指针pHandleInfo,用于存储从系统中查询到的句柄信息。
- 使用NtQuerySystemInformation函数查询系统句柄信息,如果查询结果长度不足,则重新分配内存空间并继续查询,直到获取到足够的句柄信息为止。
- 遍历句柄信息,找到指定进程ID和句柄值所对应的句柄信息,从而获取到内核对象地址,并将其存储到ppObjAddr指针所指向的内存中。
- 如果找到了对应的句柄信息,则返回0表示操作成功;否则返回其他错误代码。
- 释放之前分配的内存空间。
1 | int getobjptr(PULONG64 ppObjAddr, ULONG ulPid, HANDLE handle) |
初始化
获取System进程Token地址,获取本进程Token地址,IORing初始化,预注册缓冲初始化,这里使用了管道代替文件,所以还需要管道初始化。
1 | BOOL Init_CVE_2023_21768() |
任意地址写
1 | int ioring_write(PULONG64 pRegisterBuffers, ULONG64 pWriteAddr, PVOID pWriteBuffer, ULONG ulWriteLen) |
任意地址读
1 | int ioring_read(PULONG64 pRegisterBuffers, ULONG64 pReadAddr, PVOID pReadBuffer, ULONG ulReadLen) |
总Exploit
Exploit.cpp
1 |
|
struct.h
1 |
|
win_defs.h
1 |
|
提权结果
调试就根据自己编写代码时去调试了,主要是看_IORING_OBJECT这个内核结构体的那两个成员是否写成功。
运行exp结果如下。
参考
(58条消息) VMware虚拟机安装Win11教程(解决常见报错)_TheITSea的博客-CSDN博客
GitHub - xforcered/Windows_LPE_AFD_CVE-2023-21768: LPE exploit for CVE-2023-21768